Add alliance renewal action to Radial Menu#3095
Add alliance renewal action to Radial Menu#3095deshack wants to merge 7 commits intoopenfrontio:mainfrom
Conversation
WalkthroughAdds alliance-extension support: core APIs and eligibility checks, GameRunner exposes extension state per interaction, radial menu adds an “extend alliance” item with timer/custom render, client emits SendAllianceExtensionIntentEvent, EventsDisplay reacts, and tests cover the UI states. Changes
Sequence DiagramsequenceDiagram
participant UI as Radial Menu
participant Handler as PlayerActionHandler
participant Events as Event Bus
participant Runner as GameRunner
participant Player as PlayerImpl
participant Alliance as AllianceImpl
UI->>Runner: request interaction data (canExtendAlliance, inAllianceExtensionWindow, agreement flags)
Runner->>Player: ask canExtendAlliance(other)
Player->>Alliance: query agreedToExtend / expiresAt
Alliance-->>Player: return agreedToExtend + expiry
Player-->>Runner: return canExtendAlliance
Runner-->>UI: return interaction data
UI->>UI: render item (timer gradient / customRender)
UI->>Handler: call handleExtendAlliance(recipient)
Handler->>Events: emit SendAllianceExtensionIntentEvent(recipient)
Events->>Runner: deliver extension intent
Runner->>Alliance: inspect / update agreement state
Alliance-->>Runner: updated state
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Suggestion by tryout33:
|
5537382 to
ce2f45b
Compare
Replace clip-path overlay with CSS linear-gradient on the main arc path so the entire button is clickable. Use the 30s extension window (not the full alliance duration) for the timer fraction, and lighten the expired portion to match the name overlay style. Preserve the gradient fill on hover/mouseout so the countdown remains visible during interaction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/client/graphics/layers/RadialMenu.ts`:
- Around line 1129-1141: The refresh path in RadialMenu currently always
destroys and recreates icon children (icon.selectAll("*").remove()) before
calling item.customRender(...), which restarts SVG <animate> elements; change
refresh to first compute a small canonical state key (e.g., disabled + relevant
this.params values or a JSON of props) and compare it to a stored data attribute
on the icon (e.g., icon.attr("data-prev-state")); if the key is identical, skip
remove() and skip re-invoking item.customRender to preserve animations,
otherwise update the data-prev-state and either remove+re-render or call
item.customRender with an "update" flag so implementations can update elements
in place instead of recreating them. Ensure you update RadialMenu refresh logic
and the call-site of item.customRender so custom renderers can opt into in-place
updates.
In `@src/client/graphics/layers/RadialMenuElements.ts`:
- Around line 250-309: In customRender (inside RadialMenuElements.ts) skip
creating/attaching the pulse <animate> elements when the image is disabled:
check the disabled flag together with myAgreed/otherAgreed before creating
animLeft/animRight so no animation is appended if disabled is true; optionally,
if you also want to differentiate "me" vs "them", apply a visual flip to
rightImg (e.g., a transform scale(-1,1) or mirrored asset) rather than using the
identical allianceIcon for both.
🧹 Nitpick comments (2)
tests/client/graphics/RadialMenuElements.test.ts (1)
352-442: Good coverage for the new ally_extend element.The four tests cover visibility (in/out of extension window) and enabled/disabled states cleanly.
One small note: the
allyPlayermock object is copy-pasted across all four tests. You could extract a small helper likecreateAllyPlayer()to reduce repetition, but this is a minor nit for test code.♻️ Optional: extract shared mock
+ function createAllyPlayer(): PlayerView { + return { + id: () => 2, + isAlliedWith: vi.fn(() => true), + isPlayer: vi.fn(() => true), + } as unknown as PlayerView; + } + it("should show extend element when inAllianceExtensionWindow is true", () => { - const allyPlayer = { - id: () => 2, - isAlliedWith: vi.fn(() => true), - isPlayer: vi.fn(() => true), - } as unknown as PlayerView; + const allyPlayer = createAllyPlayer(); mockParams.selected = allyPlayer;Apply the same pattern for the other three tests.
src/client/graphics/layers/RadialMenu.ts (1)
362-371: FragilefadedColorstring manipulation, duplicated in two places.The pattern
.replace("rgb", "rgba").replace(")",, ${opacity}))assumesd3.interpolateRgbalways returns"rgb(r, g, b)". If the input or output ever includesrgba(...), the first replace produces"rgbaa(...)". This logic also appears twice (inrenderPathsandrefresh).Consider using
d3.color(...).copy({ opacity })which is already used elsewhere in this file, or extract a small helper.♻️ Safer color with opacity
+function colorWithOpacity(baseColor: string, fadeFactor: number, opacity: number): string { + const parsed = d3.color(baseColor); + if (!parsed) return baseColor; + const faded = d3.color(d3.interpolateRgb(parsed.toString(), "white")(fadeFactor)); + return faded?.copy({ opacity })?.toString() ?? baseColor; +}Then replace both occurrences:
-const fadedColor = parsed - ? d3.interpolateRgb(parsed.toString(), "white")(0.4) - .replace("rgb", "rgba") - .replace(")", `, ${opacity})`) - : normalColor; +const fadedColor = colorWithOpacity(baseColor, 0.4, opacity);Also applies to: 1175-1186
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/client/graphics/layers/RadialMenu.ts`:
- Around line 352-401: The rgba string is built via fragile string replaces in
the timer gradient color computation inside arcs.each (variable fadedColor) —
replace that manual string manipulation with d3.color-based handling: compute
the interpolated color (using d3.interpolateRgb or similar), pass its result
into d3.color(...), then call .copy({ opacity }) and .toString() to produce the
final fadedColor; update the code that sets fadedColor in the block that creates
gradientId and path.attr("fill", ...) and make the identical change in the
refresh() method where the same pattern occurs (around the fadedColor
computation near lines ~1179–1184) so both locations use d3.color(...).copy({
opacity }).toString() instead of string replacements.
🧹 Nitpick comments (3)
src/core/GameRunner.ts (1)
210-228: Alliance extension fields look correct.The logic for
inAllianceExtensionWindow,myPlayerAgreedToExtend, andotherPlayerAgreedToExtendis clear and well-structured.One small nit: the
as Playercasts on lines 216 and 227 are redundant —otheris already typed asPlayerfrom line 204. Removing them keeps the code cleaner.♻️ Remove redundant casts
- const alliance = player.allianceWith(other as Player); + const alliance = player.allianceWith(other); if (alliance) { actions.interaction.allianceExpiresAt = alliance.expiresAt(); const inWindow = alliance.expiresAt() <= this.game.ticks() + this.game.config().allianceExtensionPromptOffset(); actions.interaction.inAllianceExtensionWindow = inWindow; actions.interaction.myPlayerAgreedToExtend = alliance.agreedToExtend(player); - actions.interaction.otherPlayerAgreedToExtend = alliance.agreedToExtend( - other as Player, - ); + actions.interaction.otherPlayerAgreedToExtend = + alliance.agreedToExtend(other); }src/client/graphics/layers/RadialMenu.ts (2)
1167-1194: Duplicated timer-gradient color logic — extract a helper.The faded/normal color computation and gradient stop update logic here is nearly identical to lines 352–401 in
renderPaths. Extracting a small helper (e.g.computeTimerGradientColors(baseColor, opacity)) that returns{ normalColor, fadedColor }would remove the duplication and make both call sites easier to maintain.
354-356: Dead condition:this.params === nullis alwaysfalsehere.Line 354 already guards
this.paramsas truthy, so thethis.params === nullcheck on line 356 can never be true inside this block. Just used.data.disabled(this.params)directly.♻️ Simplify
arcs.each((d) => { if (d.data.timerFraction && this.params) { const fraction = d.data.timerFraction(this.params); - const disabled = this.params === null || d.data.disabled(this.params); + const disabled = d.data.disabled(this.params);
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Preserve SVG animate elements by comparing a canonical state key before re-invoking customRender. Renderers can opt in via customRenderStateKey and handle in-place updates via the update flag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Superseded by #3148 |
Description:
The following PR replaces the (disabled) alliance request button with an alliance extension/renewal button when the alliance with the target player is expiring.
Agreeing to renewal via radial menu also hides the message in the EventsDisplay.
Screen.Recording.2026-02-07.at.13.14.59.mov
Please complete the following:
Please put your Discord username so you can be contacted if a bug or regression is found:
deshack_82603